Next.js Dynamic Routes 業務実装パターン | 実務的な設計と応用例

React / Next.js

Next.js Dynamic Routes 業務実装パターン | 実務的な設計と応用例

Next.jsのDynamic Routesは、ECサイトの商品ページ、ブログ記事、ユーザープロフィールなど、動的に生成されるページを効率的に管理する機能です。本記事では、教科書的な説明ではなく、実際の業務で使用される実装パターンを詳しく解説します。

1. Dynamic Routesの基本概念

Dynamic Routesとは、ファイルシステムのルーティングを使用して、URLのパラメータに基づいた動的なページを生成する仕組みです。Next.jsではブラケット記法を使ってファイル名を定義します。

例えば、/products/123というURLに対応させるには、pages/products/[id].tsxというファイルを作成します。

この機能の最大の利点は、数百万個のページを事前に生成する必要がなく、必要に応じてオンデマンドで生成できる点です。これにより、ビルド時間の短縮とサーバーリソースの効率化が実現できます。

2. 業務でのユースケース

2.1 ECサイトの商品ページ管理

最も一般的なユースケースは、ECサイトの商品ページです。SKUが数万個ある場合、全ページを事前ビルドするのは現実的ではありません。Dynamic Routesを使えば、ユーザーが訪問した商品ページのみ生成されます。

2.2 ブログやコンテンツ管理

記事数が常に増え続けるブログシステムでは、Dynamic Routesが必須です。/blog/[slug]の形式で、スラッグから記事データを取得し、動的にレンダリングします。

2.3 ユーザープロフィールページ

SaaSアプリケーションでは、/users/[userId]のようなプライベートページをDynamic Routesで管理します。認証とデータベースクエリを組み合わせることで、セキュアなユーザー専用ページが実現できます。

3. 実装コード:実務的なパターン

3.1 基本的なDynamic Routeの実装

// pages/products/[id].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import { useRouter } from 'next/router';

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  imageUrl: string;
  stock: number;
}

interface Props {
  product: Product;
  fallback: boolean;
}

export default function ProductPage({ product, fallback }: Props) {
  const router = useRouter();

  if (router.isFallback) {
    return (
      

ページを読み込み中...

); } if (!product) { return (

商品が見つかりません

); } return (
{product.name}

{product.name}

¥{product.price.toLocaleString('ja-JP')}

{product.description}

在庫: 0 ? 'text-green-600' : 'text-red-600'}> {product.stock > 0 ? `${product.stock}個` : '品切れ'}

); } export const getStaticPaths: GetStaticPaths = async () => { // ビルド時に人気商品のIDを事前生成 const popularProducts = await fetchPopularProductIds(); const paths = popularProducts.map((id) => ({ params: { id: id.toString() }, })); return { paths, fallback: 'blocking', // オンデマンド生成、ユーザーを待たせない }; }; export const getStaticProps: GetStaticProps = async ({ params }) => { try { const id = params?.id as string; const product = await fetchProductFromDatabase(id); if (!product) { return { notFound: true, revalidate: 60, // 1分後に再検証 }; } return { props: { product, fallback: false }, revalidate: 3600, // 1時間ごとに再検証(ISR) }; } catch (error) { console.error('Error fetching product:', error); return { revalidate: 60, // エラー時は短い間隔で再試行 }; } }; // ダミー実装例 async function fetchProductFromDatabase(id: string): Promise { const res = await fetch(`https://api.example.com/products/${id}`); if (!res.ok) return null; return res.json(); } async function fetchPopularProductIds(): Promise { const res = await fetch('https://api.example.com/products/popular?limit=100'); const data = await res.json(); return data.map((p: any) => p.id); }

3.2 複数のパラメータを持つ実装

// pages/blog/[year]/[month]/[slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';

interface BlogPost {
  title: string;
  content: string;
  author: string;
  publishedAt: string;
  tags: string[];
}

interface Props {
  post: BlogPost;
  relatedPosts: BlogPost[];
}

export default function BlogPostPage({ post, relatedPosts }: Props) {
  return (
    

{post.title}

著者: {post.author} | {new Date(post.publishedAt).toLocaleDateString('ja-JP')}

関連記事

); } export const getStaticPaths: GetStaticPaths = async () => { // 最新100件のブログ記事のパスを生成 const posts = await fetchLatestBlogPosts(100); const paths = posts.map((post) => ({ params: { year: post.publishedAt.split('-')[0], month: post.publishedAt.split('-')[1], slug: post.slug, }, })); return { paths, fallback: 'blocking', }; }; export const getStaticProps: GetStaticProps = async ({ params }) => { const { year, month, slug } = params as Record; try { const post = await fetchBlogPost(year, month, slug); if (!post) { return { notFound: true }; } const relatedPosts = await fetchRelatedPosts(post.tags, slug); return { props: { post, relatedPosts }, revalidate: 86400, // 24時間ごとに再検証 }; } catch (error) { console.error('Error fetching blog post:', error); return { revalidate: 300, // エラー時は5分後に再試行 }; } }; async function fetchLatestBlogPosts(limit: number) { // 実装例 return []; } async function fetchBlogPost(year: string, month: string, slug: string): Promise { // 実装例 return null; } async function fetchRelatedPosts(tags: string[], currentSlug: string): Promise { // 実装例 return []; }

3.3 サーバーサイドレンダリングを使用したパターン

// pages/admin/users/[userId].tsx
import { GetServerSideProps } from 'next';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/router';

interface User {
  id: string;
  email: string;
  name: string;
  role: string;
  createdAt: string;
  lastLogin: string;
}

interface Props {
  user: User;
}

export default function AdminUserPage({ user }: Props) {
  const { data: session } = useSession();
  const router = useRouter();

  // 認証チェック
  if (!session || session.user.role !== 'admin') {
    return 
アクセス権がありません
; } return (

ユーザー詳細

{user.id}

{user.email}

{user.name}

{user.role}

{new Date(user.createdAt).toLocaleDateString('ja-JP')}

{new Date(user.lastLogin).toLocaleDateString('ja-JP')}

); } export const getServerSideProps: GetServerSideProps = async (context) => { const { userId } = context.params as Record; const session = await getSessionFromContext(context); // 認証情報取得 // 認証チェック if (!session || session.user.role !== 'admin') { return { redirect: { destination: '/login', permanent: false, }, }; } try { const user = await fetchUserById(userId); if (!user) { return { notFound: true }; } return { props: { user }, }; } catch (error) { console.error('Error fetching user:', error); return { notFound: true, }; } }; async function getSessionFromContext(context: any) { // 実装例 return null; } async function fetchUserById(userId: string): Promise { // 実装例 return null; }

4. よくある応用パターン

4.1 キャッチオールルート

すべてのパラメータパターンをキャッチしたい場合、スプレッド演算子を使用します。例えば、/docs/getting-started/installationのような複数階層のURLを処理する場合に有効です。

// pages/docs/[...slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';

interface DocPage {
  title: string;
  content: string;
  breadcrumbs: string[];
}

interface Props {
  doc: DocPage;
}

export default function DocPage({ doc }: Props) {
  return (
    

{doc.title}

); } export const getStaticPaths: GetStaticPaths = async () => { const docs = await fetchAllDocPaths(); const paths = docs.map((doc) => ({ params: { slug: doc.slug.split('/'), }, })); return { paths, fallback: 'blocking', }; }; export const getStaticProps: GetStaticProps = async ({ params }) => { const slug = (params?.slug as string[])?.join('/'); try { const doc = await fetchDocContent(slug); if (!doc) { return { notFound: true }; } return { props: { doc }, revalidate: 3600, }; } catch (error) { return { revalidate: 300, }; } }; async function fetchAllDocPaths() { return []; } async function fetchDocContent(slug: string): Promise { return null; }

4.2 オプショナルなキャッチオールルート

ルートパラメータを完全にオプショナルにしたい場合は、ダブルブラケットを使います。

// pages/search/[[...query]].tsx
import { GetStaticProps } from 'next';

interface SearchResult {
  id: string;
  title: string;
  relevance: number;
}

interface Props {
  results: SearchResult[];
  query: string;
}

export default function SearchPage({ results, query }: Props) {
  return (
    

検索結果

{query &&

「{query}」の検索結果: {results.length}件

} {results.length === 0 ? (

結果がありません

) : (
    {results.map((result) => (
  • {result.title}

    関連度: {Math.round(result.relevance * 100)}%

  • ))}
)}
); } export const getStaticProps: GetStaticProps = async ({ params }) => { const queryArray = params?.query as string[] | undefined; const query = queryArray?.join(' ') || ''; if (!query) { return { props: { results: [], query: '' }, revalidate: 86400, }; } try { const results = await performSearch(query); return { props: { results, query }, revalidate: 3600, }; } catch (error) { return { props: { results: [], query }, revalidate: 300, }; } }; async function performSearch(query: string): Promise { return []; }

4.3 API ルートとの組み合わせ

Dynamic Routesと API ルートを組み合わせることで、より柔軟なデータ取得が可能になります。特にISR(Incremental Static Regeneration)時にAPIを呼び出す場合に有効です。

// pages/api/products/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';

type ResponseData = {
  success: boolean;
  data?: any;
  error?: string;
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query;

  if (req.method === 'GET') {
    try {
      const product = await fetchProductFromDatabase(String(id));

      if (!product) {
        return res.status(404).json({ success: false, error: 'Product not found' });
      }

      return res.status(200).json({ success: true, data: product });
    } catch (error) {
      return res.status(500).json({ success: false, error: 'Internal server error' });
    }
  }

  if (req.method === 'PUT') {
    // 認証チェック
    const token = req.headers.authorization?.split(' ')[1];
    if (!token || !verifyToken(token)) {
      return res.status(401).json({ success: false, error: 'Unauthorized' });
    }

    try {
      const updatedProduct = await updateProductInDatabase(String(id), req.body);
      return res.status(200).json({ success: true, data: updatedProduct });
    } catch (error) {
      return res.status(500).json({ success: false, error: 'Internal server error' });
    }
  }

  res.setHeader('Allow', ['GET', 'PUT']);
  res.status(405).json({ success: false, error: 'Method not allowed' });
}

function verifyToken(token: string): boolean {
  // トークン検証ロジック
  return true;
}

async function fetchProductFromDatabase(id: string) {
  return null;
}

async function updateProductInDatabase(id: string, data: any) {
  return null;
}

5. 注意点と最適化のコツ

5.1 ISR(増分静的再生成)の適切な設定

revalidate値を設定することで、バックグラウンドで古いページを再生成できます。ただし、トラフィック量に応じた適切な値の設定が重要です。

  • 頻繁に更新されるコンテンツ:60~300秒
  • 定期的に更新されるコンテンツ:3600~86400秒
  • ほとんど変わらないコンテンツ:604800秒以上

ただし、eコマースの在庫情報のように非常に頻繁に変わるデータはSSRやAPIの直接呼び出しを検討してください。

5.2 fallback戦略の選択

Dynamic Routesのfallback設定には3つの選択肢があります:

  • fallback: false:事前生成されたパスのみ提供。存在しないパスは404を返す
  • fallback: ‘blocking’:未生成パスはサーバーサイドで生成してから返す。ユーザーを待たせるが、フラッシュなし
  • fallback: true:未生成パスは即座に返し、バックグラウンドで生成。スケルトンUIの実装が必要

5.3 データベースクエリの最適化

大規模なサイトでは、getStaticPropsで実行されるデータベースクエリが大量になり、ビルド時間が伸びる可能性があります。対策としては:

  • APIを経由してデータを取得し、キャッシュを活用
  • 人気ページのみ事前生成し、その他はfallback: ‘blocking’
  • データベースのインデックスを最適化

5.4 SEO対策

Dynamic Routesで生成されるページでは、メタタグの適切な設定が重要です。

import Head from 'next/head';

export default function ProductPage({ product }: Props) {
  return (
    <>
      
        {product.name} | 商品名
        
        
        
        
        
        
      
      {/* ページコンテンツ */}
    
  );
}

5.5 エラーハンドリング

予期しないエラーが発生する可能性があるため、適切なエラーハンドリングが必須です。

export const getStaticProps: GetStaticProps = async ({ params }) => {
  try {
    const data = await fetchData(params?.id as string);

    if (!data) {
      return {
        notFound: true,
        revalidate: 300, // 300秒後に再試行
      };
    }

    return {
      props: { data },
      revalidate: 3600,
    };
  } catch (error) {
    console.error('Error in getStaticProps:', error);

    // エラーログをサービスに送信(例:Sentry)
    captureException(error);

    // キャッシュされた古いデータを返すか、簡易版ページを返す
    return {
      revalidate: 60, // 1分後に再試行
    };
  }
}

6. 実務でよくあるトラブルと解決方法

6.1 ビルド時間の増加

問題:Dynamic Routesが増えると、ビルド時間が指数関数的に増加する

解決方法

  • person popular itemsのみ事前生成
  • остальは fallback: ‘blocking’ で対応
  • getStaticPathsで並列処理を活用
export const getStaticPaths: GetStaticPaths = async () => {\n  // 人気商品のみ事前生成(例:トップ100)\n  const paths = await Promise.all(\n    Array.from({ length: 100 }).map((_, i) => \n      fetchProductPaths(i * 10, (i + 1) * 10)\n    )\n  ).then(results => results.flat());\n\n  return {\n    paths,\n    fallback: 'blocking', // その他はオンデマンド\n  };\n};

6.2 ホットスポットと冷たいパス

問題:人気ページと不人気ページでレスポンス時間が大きく異なる

解決方法

  • 人気ページはキャッシュされているため高速
  • 初回訪問ページはfallback: ‘blocking’で若干遅い
  • Vercelなどのプラットフォームではオートスケーリングで対応

6.3 動的なパラメータの変更

問題:URLパラメータの形式を変更した場合、既存ページにアクセスできなくなる

解決方法:リダイレクトを実装して後方互換性を保つ

export const getStaticProps: GetStaticProps = async ({ params }) => {\n  const oldId = params?.id as string;\n  \n  // 古いIDフォーマットから新しいIDフォーマットに変換\n  const newId = convertOldIdToNewId(oldId);\n  \n  if (newId !== oldId) {\n    return {\n      redirect: {\n        destination: `/products/${newId}`,\n        permanent: true, // 301リダイレクト\n      },\n    };\n  }\n  \n  // 通常の処理\n};

7. パフォーマンス測定

Dynamic Routesの性能を測定するために、Web Vitalsを監視することが重要です。


      
タイトルとURLをコピーしました